- Görkem Güray/
- SwiftUI in 100 Days Notes/
- Day 50 - SwiftUI Networking: Observable Class and Codable, Haptic Engine/
Day 50 - SwiftUI Networking: Observable Class and Codable, Haptic Engine
Table of Contents
Today we will cover two more techniques before moving on to creating the user interface for our application. We will add Codable
support to an @Observable
class and we will examine Haptic effects.
Add Codable Compatibility to an @Observable Class #
If all the properties of a type are already Codable
compliant, the type itself can be Codable
compliant without any extra work. Swift will synthesize the code needed to archive and unarchive your type as needed. However, things get a little trickier when working with classes that use the @Observable
macro because of the way Swift rewrites our code.
To see the problem in practice, we can create a simple observable class with a single property called name
as follows.
@Observable
class User: Codable {
var name = "Taylor"
}
Now we can write a little SwiftUI code that encodes an instance of our class and prints the resulting text when a button is pressed;
struct ContentView: View {
var body: some View {
Button("Encode Taylor", action: encodeTaylor)
}
func encodeTaylor() {
let data = try! JSONEncoder().encode(User())
let str = String(decoding: data, as: UTF8.self)
print(str)
}
}
What we will see is unexpected: {"name":“Taylor”,"$observationRegistrar":{}}
The name
property now has _name
, an observation registrar instance in JSON.
Remember, the @Observable
macro is silently rewriting our class so that it can be observed by SwiftUI, and here this rewriting is leaking. We can see this happening, which can cause all kinds of problems. For example, if you are trying to send a “name “ value to a server, the server might not know what to do with "_name “.
To fix this, we need to tell Swift exactly how it should encode and decode our data. This is done by placing an enum called CodingKeys
inside our class and ensuring that it conforms to a String
raw value and the CodingKey
protocol. Yes, this is a bit confusing. the enum is called CodingKeys
and the protocol is CodingKey
, but it’s important.
Inside the enum, you need to write a case for each property you want to register and a raw value containing the name you want to give it. In our case, this means saying that _name
(the underlying storage of the name
property) should be written as a string “name “ without underscores.
@Observable
class User: Codable {
enum CodingKeys: String, CodingKey {
case _name = "name"
}
var name = "Taylor"
}
And that’s it! If you try the code again, you will see that the name
property is named correctly, and also that I don’t have the observation registrar in front of me anymore, the result is much cleaner.
This coding key mapping works both ways: When Codable
sees name
in some JSON, it will be automatically registered to the _name
property.
Adding Haptic Effects #
SwiftUI has built-in support for simple haptic effects that use Apple’s Taptic Engine to make the phone vibrate in various ways. In iOS we have two options for this: the easy one and the complete one.
Important: These haptic effects only work on physical iPhones, other devices like Macs and iPad will not vibrate.
Let’s start with the easy option. Like pages and alerts, we tell SwiftUI when to trigger the effect and it does the rest for us.
First, we can write a simple view that adds 1 to the counter every time a button is pressed;
struct ContentView: View {
@State private var counter = 0
var body: some View {
Button("Tap Count: \(counter)") {
counter += 1
}
}
}
This is all old code, so let’s make it more interesting by adding a tactile effect that is triggered every time the button is pressed, add this modifier to the button;
.sensoryFeedback(.increase, trigger: counter)
Try running this on a real device and you should feel light haptic touches every time you press the button.
.increase
is one of the built-in haptic feedback types and is best used when increasing a value such as a counter. You have .success
, .warning
, .error
, .start
, .stop
and many more to choose from.
Each of these feedback types has a different feel and while it’s tempting to go through them all and pick the ones you like best, please consider how confusing this can be for visually impaired users who rely on haptics to convey information, if your app encounters an error but you play your beloved success haptic, this can cause confusion.
If you want a little more control over your haptic effects, there is an alternative called .impact()
. There are two variants of this alternative: one where you specify how flexible your object is and how strong the effect should be, and another where you specify a weight and intensity.
For example, we might request a moderate impact between two soft objects;
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.5), trigger: counter)
Or an intense collision between two heavy objects;
.sensoryFeedback(.impact(weight: .heavy, intensity: 1), trigger: counter)
For more advanced haptic feedback, Apple gives us a framework called Core Haptics. This thing feels like a real labor of love by the Apple team behind it, and I think it was one of the real hidden gems introduced in iOS 13.
Core Haptics allows us to create highly customizable haptics by combining taps, continuous vibrations, parameter curves and more. I don’t want to go too deep into it here as it’s a bit off-topic, but I’ll give you an example so you can at least try it for yourself.
First, add this new import at the top of ContentView.swift.
import CoreHaptics
Next, we need to create a CHHapticEngine
instance as a property. This is the actual object responsible for generating the vibrations, so we need to create it before we want haptic effects.
So, add this property to the ContentView;
@State private var engine: CHHapticEngine?
We will create it as soon as the main view appears. When building the Engine you can add handlers to help the activity continue when the app is stopped, like when it goes into the background, but here we’ll keep it simple: we’ll start the engine if the current device supports haptics and print an error if it fails.
Add this method to ContentView
;
func prepareHaptics() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
do {
engine = try CHHapticEngine()
try engine?.start()
} catch {
print("There was an error creating the engine: \(error.localizedDescription)")
}
}
Now for the fun part: we can configure parameters that control how strong the haptic should be (.hapticIntensity
) and how “sharp” it is (.hapticSharpness
), then place them in a haptic event with a relative time offset.
Add this method to ContentView
;
func complexSuccess() {
// make sure that the device supports haptics
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
var events = [CHHapticEvent]()
// create one intense, sharp tap
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 1)
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0)
events.append(event)
// convert those events into a pattern and play it immediately
do {
let pattern = try CHHapticPattern(events: events, parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: 0)
} catch {
print("Failed to play pattern: \(error.localizedDescription).")
}
}
To try out our custom haptics, change the body property of the ContentView as follows;
Button("Tap Me", action: complexSuccess)
.onAppear(perform: prepareHaptics)
The addition of the onAppear()
method ensures that the haptic system is initialized so that the touch gesture works correctly.
If you want to experiment more with haptics, replace the let intensity
, let sharpness
and let event
lines with the haptics you want. For example, if you replace these three lines with the following code, you will get several touches with increasing and decreasing intensity and sharpness;
for i in stride(from: 0, to: 1, by: 0.1) {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(i))
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(i))
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: i)
events.append(event)
}
for i in stride(from: 0, to: 1, by: 0.1) {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(1 - i))
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(1 - i))
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 1 + i)
events.append(event)
}
Experimenting with Core Haptics is a lot of fun, but considering how much work it requires, I think you should stick to the built-in effects as much as possible.
This brings us to the end of the overview for this project, so please return ContenView.swift to its original state so we can start building the main project.
Getting Basic Order Details #
The first step in this project will be to create an order screen that takes the basic details of an order: how many cupcakes they want, what kind they want and if there are any customizations.
Before moving on to the user interface, we need to start by defining the data model. Previously we used structs and classes together to get the right result, but here we will implement a different solution: we will have a single class that stores all our data and will be passed from screen to screen. This means that all the screens in our application share the same data, and as you will see it will work really well.
For now this class won’t need a lot of propert:
- Cake type, plus a static array of all possible options.
- How many cakes the user wants to order.
- Whether the user wants to make special requests that will show or hide extra options in our user interface.
- Whether the user wants extra icing on their cakes.
- Whether the user wants to add sprinkles on top of their cake.
Each of these needs to update the UI when changed, which means we need to make sure that the class uses the @Observable
macro.
So, please create a new Swift file called Order.swift, change the Foundation import to SwiftUI and write this code;
@Observable
class Order {
static let types = ["Vanilla", "Strawberry", "Chocolate", "Rainbow"]
var type = 0
var quantity = 3
var specialRequestEnabled = false
var extraFrosting = false
var addSprinkles = false
}
Now by adding this property we can create a single instance of it in ContentView
.
@State private var order = Order()
This is the only place where the order will be created, this property will be transferred to all other screens in our application, so they will all work with the same data.
For this screen we will create the user interface in three sections, starting with the cake type and quantity. This first section will show a picker that will allow the user to choose between Vanilla, Strawberry, Chocolate and Rainbow cakes and then a stepper with a range of 3 to 20 to select the quantity. All this will be placed inside a form, which itself will be in a navigation stack, so we can set a title.
There is a small problem here: our cupcake topping list is a string array, but we store the user’s selection as an integer. How can we match these two? An easy solution is to use the array’s indices property, which gives us a position of each item, which we can then use as an array index. This is a bad idea for variable arrays because the order of the array can change at any time, but here our array order will never change, so it’s safe.
Place this now in the body of the ContentView:
NavigationStack {
Form {
Section {
Picker("Select your cake type", selection: $order.type) {
ForEach(Order.types.indices, id: \.self) {
Text(Order.types[$0])
}
}
Stepper("Number of cakes: \(order.quantity)", value: $order.quantity, in: 3...20)
}
}
.navigationTitle("Cupcake Corner")
}
The second part of our form will have three toggle switches connected to specialRequestEnabled , extraFrosting and addSprinkles respectively. However, the second and third switches should only be visible when the first one is enabled, so we will enclose them in a condition.
Section {
Toggle("Any special requests?", isOn: $order.specialRequestEnabled)
if order.specialRequestEnabled {
Toggle("Add extra frosting", isOn: $order.extraFrosting)
Toggle("Add extra sprinkles", isOn: $order.addSprinkles)
}
}
Go ahead and run the application again and try.
However, there is an error and we created it. If we enable special requests, then enable one or both of “extra frosting” and “extra sprinkles”, then disable the special requests, our previous special request selection remains enabled. This means that when we enable the special requests again, the previous special requests are still active.
If every layer of our code is aware of this (your application, server, database, etc. are programmed to ignore extraFrosting
and addSprinkles
when specialRequestEnabled
is set to false), this kind of problem is not hard to overcome. However, a better idea - and safer - is to make sure that both extraFrosting
and addSprinkles
are reset to false when specialRequestEnabled
is set to false.
We can accomplish this by adding a didSet
property observer to specialRequestEnabled
.
var specialRequestEnabled = false {
didSet {
if specialRequestEnabled == false {
extraFrosting = false
addSprinkles = false
}
}
}
Our third part is the easiest, because we will only have a NavigationLink
pointing to the next screen. We don’t have a second screen, but we can add it quickly enough. Create a new SwiftUI view called “AdressView” and give it an order
property as follows.
struct AddressView: View {
var order: Order
var body: some View {
Text("Hello World")
}
}
#Preview {
AddressView(order: Order())
}
We will make this more useful shortly, but for now we will return to ContentView.swift and add the last section for our form. This will create a NavigationLink
pointing to the AddressView
and pass the current order object.
Add this last section now.
Section {
NavigationLink("Delivery details") {
AddressView(order: order)
}
}
This completes our first screen, so you can try it out before moving on.
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.